A

React Compiler 原理:它凭什么能替你写 useMemo

2026-06-17 23:10

我们写了好几年的 useMemo / useCallback,本质上是在替编译器做一件它本该自动完成的事。React Compiler 想把这件事彻底收回去——它的野心不是“帮你优化”,而是让你忘记优化这回事(它的早期代号就叫 Forget)。

之前我写过一篇开了 React Compiler 之后的实战记录,讲打开 Compiler 后遇到的几个 ESLint 报错。那篇偏“踩坑”,这篇我想把原理讲透:React Compiler 到底怎么工作的、它凭什么敢替你做记忆化、它的边界在哪、和 Signals 那套又有什么本质区别。

先回到那个老问题:我们为什么要手写记忆化

要理解 Compiler,得先理解它要解决的痛点。这事其实和 React 的更新模型一脉相承。

React 的渲染是纯函数式的:状态一变,组件函数就从头到尾重新执行一遍,产出新的 UI 描述,再去和上一棵树 diff。这套模型简单可预测,但有个副作用——每次渲染,函数体里的对象、函数、派生计算全都重新生成一遍

JSX
function List({ items }) { // 每次渲染都是一个全新的数组引用 const sorted = items.slice().sort(cmp); // 每次渲染都是一个全新的函数引用 const onClick = () => doSomething(); return <Child data={sorted} onClick={onClick} />; }

大多数时候这无所谓。但只要 ChildReact.memo 包了,或者 sorted / onClick 进了某个 useEffect 的依赖数组,“每次都是新引用”就会击穿缓存、触发本可避免的重渲染或 effect 重跑。

于是我们手动兜底:

JSX
const sorted = useMemo(() => items.slice().sort(cmp), [items]); const onClick = useCallback(() => doSomething(), []);

这套写法有三个老毛病:

  1. 机械:你在替机器做"输入变没变"的判断。
  2. 易错:依赖数组写漏一个,就是一个潜伏的 bug。
  3. 传染:为了让 memo 生效,你得把整条链路上的值全包一遍,否则中间断一环就白费。

React Compiler 的目标就一句话:把这三件事全自动化。

它怎么做到的:自动生成一个缓存

React Compiler 是一个编译期工具,跑在 Babel/SWC 阶段。它读你的组件源码,做静态分析,然后改写成带缓存的等价代码。

核心机制可以这么理解:编译器给每个组件分配一个缓存槽数组,把渲染过程中“可能重复创建的值”都存进去,每次渲染先比较输入有没有变,没变就直接复用上一次的结果。

拿上面那段举例,编译后概念上长这样(真实产物来自 react/compiler-runtime,这里简化):

JSX
import { c as _c } from "react/compiler-runtime"; function List({ items }) { const $ = _c(4); // 申请 4 个缓存槽 // sorted:只有 items 变了才重算 let sorted; if ($[0] !== items) { sorted = items.slice().sort(cmp); $[0] = items; $[1] = sorted; } else { sorted = $[1]; } // onClick:没有依赖,只在首次创建 let onClick; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { onClick = () => doSomething(); $[2] = onClick; } else { onClick = $[2]; } // 连 JSX 本身也缓存:sorted / onClick 都没变就复用整个 element let t; if ($[3] !== sorted || /* ... */) { t = <Child data={sorted} onClick={onClick} />; $[3] = sorted; } else { t = /* 上次的 element */; } return t; }

这里有两个关键点,是它比人手写更强的地方:

  • 粒度更细:它不只缓存 useMemo 那种"大块计算",连单个表达式、甚至 JSX element 本身都能独立缓存。人手写不可能做到这么细——你不会真的去 useMemo 一个 <Child />
  • 依赖自动推导:依赖数组是它算出来的,不是你写的,所以不会漏、不会错。

换句话说,Compiler 做的是一种自动、细粒度、不会写错的 useMemo。这就是它敢说"以后别手写 memo"的底气。

它的前提:Rules of React

但天下没有免费的优化。编译器敢缓存一个值,是因为它假设这个值在输入不变时就不会变——也就是假设你的代码是的。

这套假设被官方整理成了 Rules of React,核心几条:

  • 组件和 Hook 必须是纯函数:相同输入产出相同结果。
  • 渲染期不能有副作用:不能在渲染过程中 mutate props、state,或任何已经渲染用过的值。
  • props 和 state 视为不可变
  • Hook 的调用顺序固定,不能放进条件/循环。

为什么这些规则突然变"硬"了?因为在没有 Compiler 时,违反它们往往只是"偶尔出 bug";但有了 Compiler,它会基于这些假设去缓存——你一旦在渲染期偷偷改了某个值,缓存就和真实状态对不上,UI 就会错乱。

违规时会发生什么:Bailout,而不是报错崩溃

这是面试里很容易答错的一个点。

React Compiler 不是那种"你写错就编译失败"的强约束工具。它的策略是保守的、安全第一的

当它分析一个组件,发现没法确定这段代码是否安全(比如你违反了 Rules of React,或者写了它看不懂的可变操作),它不会硬优化,而是直接放弃这个组件(bail out),原样输出未优化的代码。

也就是说,最坏情况只是"这个组件没被优化",而不是"代码跑挂了"。这是它能在大型存量项目里渐进接入的关键——优化不了的地方,退化成跟以前一样而已。

我在实战篇里遇到的 preserve-manual-memoization 报错就是这个机制的体现:我手写的 useMemo 它没法安全复刻,于是它跳过了整个组件,并通过 ESLint 提醒我"这里我放弃优化了"。配套的 eslint-plugin-react-hooks(v6)就是用来在编码阶段就把这些"会导致 bailout 或语义错误"的写法标出来。

它替代什么,又不做什么

替代(接入后这三件套基本可以不写了):

  • useMemo
  • useCallback
  • React.memo

不替代 / 不做(这点尤其要清楚,容易被追问):

  • 不会帮你修错误的副作用。像"用 effect 同步派生状态"这种反模式,它的态度是报错让你改,而不是默默帮你改对。
  • 不接管 useEffect 的语义——副作用还是你的责任。
  • 不改变 React 的渲染模型:组件该重渲染还是重渲染(见 Fiber),它只是让"重渲染时少做无用功"。

记住这条边界:Compiler 优化的是"重渲染时的计算与引用稳定性",不是"要不要重渲染"本身。

渐进接入与逃生舱

它被设计成可以一点点接入:

  • 按目录/文件开启:可以只对部分路径启用编译。
  • "use no memo" 指令:在某个组件顶部加这行字符串指令,就能让 Compiler 跳过它——遇到疑似被 Compiler 改出问题的组件时,这是排查的第一手段。
  • 和手写 memo 共存:存量的 useMemo 不用急着删,Compiler 会尝试保留(保留不了才报 preserve-manual-memoization)。

在 Expo / React Native 里,开启方式就是 app.json 里的 experiments.reactCompiler: true;在普通 React 项目则是配 Babel 插件 babel-plugin-react-compiler

横向对比:编译时 vs 运行时 vs Signals

这是最能体现理解深度的部分,也是面试的拔高题。

React Compiler:重渲染 + 自动缓存

React 的世界观始终是"状态变 → 组件函数重跑 → diff → 提交"。Compiler 没有改变这个世界观,它只是在"组件函数重跑"这一步里,把不必要的重复计算缓存掉。本质上还是粗粒度的重渲染 + 缓存优化

Svelte / Solid 的预编译:把框架"编译没了"

Svelte、Solid 这类也是编译时方案,但路子更激进:它们在编译期就把"状态和 DOM 的对应关系"分析出来,生成直接操作 DOM 的命令式代码,运行时几乎没有"虚拟 DOM + diff"这一层。所以它们没有"组件整体重渲染"的概念。

Signals:细粒度响应式,根本不重渲染

Vue、Solid、Preact Signals、新版 Angular 用的是信号量(Signals)——一种细粒度响应式。它的思路和 React 完全相反:

  • React(含 Compiler):状态变 → 整个组件函数重跑 → 用缓存跳过没变的部分。
  • Signals:状态变 → 只通知真正依赖它的那几个 DOM 节点更新 → 组件函数根本不重跑。

一句话概括这个分野:

React Compiler 是"让重渲染变便宜",Signals 是"压根不重渲染"。 一个在优化既有模型,一个在换一套模型。

理解了这点,你就能回答"React 为什么不直接上 Signals"——因为那意味着推翻 Fiber 那套可中断、可调度的并发模型(这正是我在 React 更新模型与调度里聊过的:React 的工程核心是"调度任务",而不是"拦截数据")。Compiler 是在不动摇这个根基的前提下,把开发者从手动优化里解放出来。

现状

  • React Compiler 随 React 19 趋于稳定,配套 eslint-plugin-react-hooks v6 提供编码期校验。
  • 它是可选的:不开,React 一切照旧;开了,则要求代码更严格地遵守 Rules of React。
  • 生态(Next.js、Expo 等)都已提供一键开关。

小结

维度React Compiler
本质编译期自动生成细粒度记忆化代码
替代useMemo / useCallback / React.memo
前提代码遵守 Rules of React(纯、不可变、无渲染期副作用)
违规后果bail out 跳过该组件,不崩溃,最多不优化
不做的事不修副作用、不改渲染模型、不决定"要不要重渲染"
vs Signals让重渲染变便宜 ≠ 压根不重渲染

回到开头那句:它的代号是 Forget。最理想的状态,是你写组件时完全忘记"性能优化"这件事,只管把逻辑写成干净的纯函数——剩下的,编译器替你记得。

配套的实战踩坑见这一篇